于 2023 年 8 月 27 日在 Balancer 上发生的安全事件,由于 「Reset Rate to 0 Supply」和 「舍入错误」的漏洞,攻击者在 Balancer 上盗取了价值逾 1M 美元的虚拟货币。
对于 Linear Pools 来说,其 main Token 和 BPT token 对应的缩放因子都是定值。
对于 Boosted Pools 来说,实际缩放因子 = 原始缩放因子 × Token Rate。Token Rate 的计算方法在下文中会提及。
Token Rate 的计算方式如下所示,其中 Main Balance 和 Wrapped Balance 以及 BPT Balance 都需要先经过 标准化(nominal):
以下是 Balancer Aave Boosted Pool (USDC) (bb-a-USDC) getRate
函数的源码
function getRate() external view override returns (uint256) {
bytes32 poolId = getPoolId();
(, uint256[] memory balances, ) = getVault().getPoolTokens(poolId);
_upscaleArray(balances, _scalingFactors());
(uint256 lowerTarget, uint256 upperTarget) = getTargets();
LinearMath.Params memory params = LinearMath.Params({
fee: getSwapFeePercentage(),
lowerTarget: lowerTarget,
upperTarget: upperTarget
});
uint256 totalBalance = LinearMath._calcInvariant(
LinearMath._toNominal(balances[_mainIndex], params),
balances[_wrappedIndex]
);
// Note that we're dividing by the virtual supply, which may be zero (causing this call to revert). However, the
// only way for that to happen would be for all LPs to exit the Pool, and nothing prevents new LPs from
// joining it later on.
return totalBalance.divUp(_getApproximateVirtualSupply(balances[_bptIndex]));
}
Nominal 的计算遵循如下公式:
以下是 Balancer Aave Boosted Pool (USDC) (bb-a-USDC) _toNominal
函数的源码:
function _toNominal(uint256 real, Params memory params) internal pure returns (uint256) {
// Fees are always rounded down: either direction would work but we need to be consistent, and rounding down
// uses less gas.
if (real < params.lowerTarget) {
uint256 fees = (params.lowerTarget - real).mulDown(params.fee);
return real.sub(fees);
} else if (real <= params.upperTarget) {
return real;
} else {
uint256 fees = (real - params.upperTarget).mulDown(params.fee);
return real.sub(fees);
}
}
BatchSwap 是 Balancer V2 提供的一个支持“批量交换”的功能接口,支持通过 GIVEN_IN
估算 GIVEN_OUT
,也支持相反操作,最终一并结算所有交换执行完毕的交换结果。
onSwap
以 StablePhantom Pool 为例,当用户可以在其中的某个线性池(如 bb-a-USDC Pool)中进行涉及 Main Balance、Wrapped Balance 和 BPT Balance 的兑换,也可以跨线性池使用 BPT Balance 进行交易(如使用 bb-a-USDC
兑换 bb-a-DAI
)。前者的交易调用的是线性池自身的 onSwap
函数,而后者的交易调用的是 StablePhantom Pool 的 onSwap
函数。
由于涉及跨线性池的兑换交易,在进行兑换数额换算的过程中,需要获取对应线性池 BPT Balance 的 Token Rate,在 StablePhantom Pool 中,它会先获取缓存好的 Rate 进行换算,并检查是否要更新 Rate。
以下是 StablePhantom Pool onSwap
函数的源代码,可以看出 Rate 的更新和使用次序:
function onSwap(
SwapRequest memory swapRequest,
uint256[] memory balances,
uint256 indexIn,
uint256 indexOut
) public virtual override onlyVault(swapRequest.poolId) returns (uint256) {
_validateIndexes(indexIn, indexOut, _getTotalTokens());
// 先通过 _scalingFactors() 获取缓存好的 scalingFactors
uint256[] memory scalingFactors = _scalingFactors();
return
swapRequest.kind == IVault.SwapKind.GIVEN_IN
// 再通过 _swapGivenIn / _swapGivenOut 更新 scalingFactors 的缓存
? _swapGivenIn(swapRequest, balances, indexIn, indexOut, scalingFactors)
: _swapGivenOut(swapRequest, balances, indexIn, indexOut, scalingFactors);
}
以下是执行 BatchSwap
时,涉及 onSwap
以及 Rate 更新的调用示意图:
graph TD F1(Balancer: Vault.batchSwap) F2(Balancer: Vault._swapWithPools) F3(Balancer: Vault._swapWithPool) F4(Balancer: _processGeneralPoolSwapRequest) F5(bb-a-USDC: onSwap) F6(bb-a-USDC: _onSwapGivenIn) F7(bb-a-USDC: _downscaleDown) F8(bb-a-USDC: FixedPoint.divDown) F9(StablePhantomPool: onSwap) F10(StablePhantomPool: _scalingFactors) F11(StablePhantomPool: _swapGivenIn) F12(StablePhantomPool: _onSwapGivenIn) F13(StablePhantomPool: _cacheTokenRatesIfNecessary) F14(StablePhantomPool: _cacheTokenRateIfNecessary) F15(StablePhantomPool: _updateTokenRateCache) F16(StablePhantomPool: getTokenRate) F17(StablePhantomPool: getRate) V1([Cache Rate]) F1-->F2-->F3-->F4-->F5-->F6-->F7-->F8 F9-->F10-->F16-->F17-- Read -->V1 F4-->F9-->F11-->F12-->F13-->F14-->F15-- "Write bb-a-USDC.getRate()" -->V1
bb-a-USDC Pool 在通过 onSwap
函数计算兑换金额时,会调用 _downscaleDown
函数,此函数本身是一个向下取整的除法,存在四舍五入的精度误差。
在 bb-a-USDC Pool 换算 BPT Balance 和 Main BPT Balance 时,会调用 _downscaleDown
函数(最终调用的是 FixedPoint.divDown
函数),此函数使用向下取整,当 amountOut
小于 1,000,000,000,000 时,返回值将始终向下舍入为零。
FixedPoint.divDown
源码
function divDown(uint256 a, uint256 b) internal pure returns (uint256) {
_require(b != 0, Errors.ZERO_DIVISION);
if (a == 0) {
return 0;
} else {
// ONE = 1e18
uint256 aInflated = a * ONE;
_require(aInflated / a == ONE, Errors.DIV_INTERNAL); // mul overflow
return aInflated / b;
}
}
onSwap
源码
function onSwap(
SwapRequest memory request,
uint256[] memory balances,
uint256 indexIn,
uint256 indexOut
) public view override onlyVault(request.poolId) whenNotPaused returns (uint256) {
...
if (request.kind == IVault.SwapKind.GIVEN_IN) {
// The amount given is for token in, the amount calculated is for token out
request.amount = _upscale(request.amount, scalingFactors[indexIn]);
uint256 amountOut = _onSwapGivenIn(request, balances, params);
// amountOut tokens are exiting the Pool, so we round down.
// 四舍五入向下取整,scalingFactors[indexOut] 为 1e30
// _downscaleDown(...) = amountOut * 1e18 / 1e30 = amountOut / 1e12
return _downscaleDown(amountOut, scalingFactors[indexOut]);
}
...
}
开发者在写 bb-a-USDC Pool 的时候,做了取巧的处理,为了方便合约的初始化,通过 BPT Balance 的值是否为 0 的方式判断合约是否是在初始化状态,并在此状态下设置兑换比例为 1:1。
具体的实现为:在 bb-a-USDC Pool 的 _calcBptOutPerMainIn
函数中,当 BPT Balance 的余额为 0 时,Main Balance(USDC
) 和 BPT Balance(bb-a-USDC
) 进行等额兑换,具体可见下方代码:
function _calcBptOutPerMainIn(
uint256 mainIn,
uint256 mainBalance,
uint256 wrappedBalance,
uint256 bptSupply,
Params memory params
) internal pure returns (uint256) {
// Amount out, so we round down overall.
if (bptSupply == 0) { // Reset Rate on 0 Supply 漏洞的代码段
// BPT typically grows in the same ratio the invariant does. The first time liquidity is added however, the
// BPT supply is initialized to equal the invariant (which in this case is just the nominal main balance as
// there is no wrapped balance).
return _toNominal(mainIn, params);
}
uint256 previousNominalMain = _toNominal(mainBalance, params);
uint256 afterNominalMain = _toNominal(mainBalance.add(mainIn), params);
uint256 deltaNominalMain = afterNominalMain.sub(previousNominalMain);
uint256 invariant = _calcInvariant(previousNominalMain, wrappedBalance);
return Math.divDown(Math.mul(bptSupply, deltaNominalMain), invariant);
}
而由于 bb-a-USDC Pool 缺少对 BPT 余额的约束,使得攻击者能够通过正常的兑换交易将 BPT 消耗到 0,以利用 1:1 兑换的特性,在借用 bb-a-USDC(a BPT Token) 获取超额收益后,再通过等额兑换归还 bb-a-USDC。
在调用 batchSwap 过程中发生的主要交易如下图所示:
(主要异常并产生收益的交易发生在 3.4、3.5 和 3.7)
106,520,941,720,947,982,631,590
wei20,000,000,000
wei
divDown
产生的四舍五入误差,在提供 0 USDC 的情况下,取走 775,114,420,171
wei,并造成较大的兑换比例溢价(此时 Rate 为 40.24 但未在 StablePhantomPool 中更新)_scalingFactors
获取缓存的 Rate(1.02),并计算出可兑换的 bb-a-DAI
的数量,然后调用 _swapGivenIn
计算 GivenIn
并更新 Rate(40.24)bb-a-USDC
兑换获取大量 bb-a-DAI
(Rate = 40.24)bb-a-USDC
获取大量 bb-a-USDT
(Rate = 40.24)bb-a-USDC
都被借了出来bb-a-USDC
的余额为 0,在 _calcBptOutPerMainIn
时进行等额交换由于 bb-a-USDC 在计算兑换的 Main Balance 数额时存在向下舍入的数额误差(_calcMainOutPerBptIn
函数),导致攻击者可以在保持 Pool 的 Main Balance 数量不变的情况下,变动 BPT Balance 的数量。具体计算过程如下图:
_calcMainOutPerBptIn
代码
function _calcMainOutPerBptIn(
uint256 bptIn,
uint256 mainBalance,
uint256 wrappedBalance,
uint256 bptSupply,
Params memory params
) internal pure returns (uint256) {
// Amount out, so we round down overall.
uint256 previousNominalMain = _toNominal(mainBalance, params);
uint256 invariant = _calcInvariant(previousNominalMain, wrappedBalance);
uint256 deltaNominalMain = Math.divDown(Math.mul(invariant, bptIn), bptSupply);
uint256 afterNominalMain = previousNominalMain.sub(deltaNominalMain);
uint256 newMainBalance = _fromNominal(afterNominalMain, params);
return mainBalance.sub(newMainBalance);
}
在得到 amountOut = 784,399,492,780
后,onSwap
函数通过 _downscaleDown(amountOut, scalingFactors[indexOut]
语句计算最终入池的 USDC
数量,如下公式所示:
因为在交易 3.2 中,bb-a-USDC
被提取出了 775,114,420,171
,导致此时的 BPT Balance(bb-a-USDC
) 实际供应量只有 20,000,000,000
而非 795,114,420,171
,但由于舍入偏差,Main Balance(USDC
) 并没有任何进账,这也 Rate 由 1.01 膨胀至 40.24。
情况 | realMainBalance | nominalMainBalance | Virtal Supply | Rate |
---|---|---|---|---|
3.2 交易前 | 579,884,024,000,000,000,000 | 804,800,000,000 | 795,114,420,171 | 1.012 |
3.2 交易后(非四舍五入) | 579,884,023,215,600,507,220 | 20,400,507,220 | 20,000,000,000 | 1.02 |
3.2 交易后(四舍五入) | 579,884,024,000,000,000,000 | 804,800,000,000 | 20,000,000,000 | 40.240 |
根据上表可以发现,因为 divDown
造成的四舍五入,导致 Real 存在 1e12 范围内的偏差,而导致 nominalMainBalance
发生了几个数量级的变化(20,400,507,220
-> 804,800,000,000
),从而 Rate
也膨胀了四十倍。
在交易 3.3 时,StablePhantomPool 通过 _swapGivenIn
更新了 bb-a-USDC
对应的 Rate 值,具体计算过程如下图所示(可对照 toNominal
函数):
bb-a-DAI
和 bb-a-USDT
bb-a-USDC
操纵为 0在交易 3.6 中,由于 bb-a-USDC Pool 缺少对 BPT 数量的检测(控制其始终大于 0),导致攻击者可以轻松地将剩余的所有 BPT 租借出来(通过下图也可发现,USDC
和 bb-a-USDC
的兑换比率从原来的接近 1:1 膨胀到了 40:1)。
其实此时攻击者是否利用舍入漏洞“白嫖” bb-a-USDC 也并不重要,因为其数额实在太小。
交易 3.7 是 攻击者套利的重要一环,因为按照 Rate 的计算方式,此时偿还 bb-a-USDC
,理应支付 40 倍的 USDC,那么攻击者是没有套利空间的。换句话说,即使通过 40 倍的溢价兑换了其他代币,但仍需在 40 倍溢价的条件下偿还租借的 bb-a-USDC
。
但是 bb-a-USDC Pool 中存在一个致命的漏洞,就是当 bb-a-USDC = 0
时,bb-a-USDC
和 USDC
的兑换比率为 1:1!(这一点在「Reset Rate on 0 supply」章节有详细代码)
那么攻击者就可以轻松偿还租借的 bb-a-USDC
,并独享其中的 39 倍的差额利润。
Balancer 官方原文:就其本身而言,提高代币的利率是可以的(这就是为什么我们在设计过程中并不担心它)。只要利率保持在高位,就没有办法在不遭受巨大损失的情况下还清债务。
⚠️但是事实证明,由于精度造成的舍入误差,Balancer 在计算 Rate 的时候发生了几十倍的偏差,而正是这样的偏差使得攻击者能够轻松制造出高额溢价,是其获利的一个重要原因。
😃当然我也认同 Balancer 官方的看法,因为根据其计算公式,如果不存在「Reset Rate on 0 supply」这个漏洞,攻击者的套利空间是很小甚至几乎没有的,正是因为攻击者可以在 bb-a-USDC
价格高度膨胀后利用漏洞进行 1:1 的偿还,打破数学上的约束,才形成了巨额的套利空间。